Skip to content

fix(profiles): say why a profile save failed without the server's text - #3098

Draft
matt2e wants to merge 6 commits into
errors/user-copy-messagesfrom
errors/profile-save
Draft

matt2e wants to merge 6 commits into
errors/user-copy-messagesfrom
errors/profile-save

Conversation

@matt2e

@matt2e matt2e commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Profile Settings printed the failure's own message when a save failed: the server's internal error, a relay restriction such as restricted: you are timed out until 1794600000, or a JSON parser error when a proxy answered with an HTML page. Saves now fail with typed errors, and Profile Settings says what happened in plain words.

Stacked on #3096: this PR targets errors/user-copy-messages (PR #3096), which adds the shared userCopy/refusalReason helper used here. Review and merge #3096 first.

What changed

Typed save failures (communities/api.ts, communities/native-api.ts, both hosts)

  • communityRequest checks response.ok before parsing, so a proxy's HTML error page is no longer a JSON parser error.
  • A non-transient failure throws CommunityRefused (the status plus the bounded refusal reason). The relay's OK false becomes PublishRejected, and an unconfirmed publication is ReadError("invalid-response"). Callers that match on the message keep working.

Profile Settings copy

Case Before After
Not sent, 5xx or unknown the server's text, e.g. internal error Couldn’t save your profile. Try again. (Save stays enabled)
Refused with a relay restriction restricted: you are timed out until 1794600000 This community didn’t accept your profile change. You’re timed out in this community.
Refused, no known reason the relay's text This community didn’t accept your profile change.
Saved but not confirmed Your profile change is not current. Your edits are retained; save again to retry. Buzz couldn’t confirm your profile was saved. Save again to check.

Shared refusal reason (user-copy.ts): restricted: you are timed out until <ts> now reads You’re timed out in this community. It no longer parses or shows the timestamp from the relay's text. In #3096 it reads You’re timed out until <date>.

Before / After

profile save answered 500

Profile save answered 500: the server's internal error becomes Couldn’t save your profile. Try again. Save stays enabled.

profile save refused with a timeout restriction

A 403 restricted: you are timed out until 1794600000: the relay's text becomes This community didn’t accept your profile change. You’re timed out in this community. The timestamp is not shown.

Checks

  • pnpm typecheck: clean on this branch (on top of fix(errors): plain copy for failed history reads and connections #3096).
  • vitest run on user-copy.test.ts, ProfileSettings.avatar.test.tsx, communities/api.test.ts, communities/native-api.test.ts: 4 files, 110 tests passed.
  • Pre-commit hooks (biome, literal-names, icons, sadscan, git-lfs) passed on the commit.
  • pnpm lint and the full unit suite were not run for this snapshot.

Note

  • No end time. The timeout's end is intentionally not shown: displaying it would mean parsing a date out of the relay's free-text error, so the copy names the refusal without it.

🤖 Generated with Claude Code

matt2e and others added 2 commits November 9, 2026 15:28
…the relay's words

Add userCopy(error, context) in features/relay. It classifies a failure
by the typed errors Buzz already produces (ReadError kind and status,
ApiPaused/ApiCapacity, UploadError, PublishRejected/PublishNotSent,
fetch's network TypeErrors, DOMException names) and returns { text,
retry, kind, details }. text is the site's own sentence plus a plain
next step and never carries an exception name, HTTP status, URL or raw
relay or native string; those stay in details. ErrorDetails renders
details under a collapsed "Details" disclosure.

The relay layer keeps failures typed instead of stringified:
- The service snapshot stores a RelayFailure ({ kind, details }) and
  channel windows and thread snapshots keep a WindowFailure. History
  parse failures are ReadError("invalid-response").
- The window store records errorSource "older" only when loadOlder
  failed, so a head or catch-up failure no longer blames older history.
- PublishNotSent carries the relay's wait for a quota hint or a held
  lane, so busy copy can say how long.
- Copy and retry share one set of fetch network messages (including
  WebKit's), and a body cut off after its headers is an unavailable read.
- An answer Buzz can't use is not retried at once. The service asks
  again in the background after 30 s, 1, 2 and every 5 minutes, keeping
  the failure on screen meanwhile.

History reads: ChannelBody, ChannelTimeline, NewMessage, ThreadPanel,
MediaReviewViewer and DraftPreview map the failure with historyFailure():
"Messages didn’t load." with "Try again" for an outage or 5xx; "Buzz is
busy. Try again in a few seconds." (or "in about N minutes/hours") when
paused; "Check for updates" and no Try again for a 4xx or invalid
answer; "You don’t have access to this conversation." for a refusal with
no known reason. The older edge says "Older messages didn’t load.".

Connection failures: Workflows and Notes show the Messages
ConnectionFailed card: "Can’t connect", "Check your connection. We’ll
keep trying." and "Try again".
An answer Buzz can't use reads "Buzz couldn’t connect to this
community." with "Check for updates"; a refusal shows the community
refusal title with no retry. Bestie, Pinned Bestie, Activity, its
preview and Reminders say the same through connectionFailureText and
connectionFailureAction. App's page failure cards put the primary
action last and drop Try again for an unusable connection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: mtoohey <mtoohey@squareup.com>
Profile Settings printed reason.message, so a failed save showed the
server's "internal error", a relay restriction such as "restricted: you
are timed out until 1794600000", or a JSON parser error when a proxy
answered with an HTML page.

communityRequest now checks response.ok before parsing, on both hosts.
A non-transient failure throws CommunityRefused (the status plus the
bounded refusal reason), the relay's OK false becomes PublishRejected,
and an unconfirmed publication is ReadError("invalid-response").
Callers that match on the message keep working.

Profile Settings maps the failure by where the save stopped:
- not sent, 5xx or unknown: "Couldn’t save your profile. Try again."
  (Save stays enabled)
- refused: "This community didn’t accept your profile change." plus a
  known reason
- not confirmed: "Buzz couldn’t confirm your profile was saved. Save
  again to check." (it said "Your profile change is not current. Your
  edits are retained; save again to retry.")

The shared refusal reason for "restricted: you are timed out until
<ts>" now reads "You’re rate limited until Nov 13, 8:00 PM." (or
"You’re rate limited." without a readable end) instead of "You’re timed
out until …".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: mtoohey <mtoohey@squareup.com>
matt2e and others added 3 commits November 10, 2026 02:51
The shared refusal reason parsed the Unix timestamp out of the relay's
"restricted: you are timed out until <ts>" text and formatted it as a
date. That meant extracting data from free-form error text.

refusalReason now matches the phrase only and returns the fixed copy
"You’re timed out in this community." The formatUntil helper and the
Date-based test expectations are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…the relay's words

Add userCopy(error, context) in features/relay. It classifies a failure
by the typed errors Buzz already produces (ReadError kind and status,
ApiPaused/ApiCapacity, UploadError, PublishRejected/PublishNotSent,
fetch's network TypeErrors, DOMException names) and returns { text,
retry, kind, details }. text is the site's own sentence plus a plain
next step and never carries an exception name, HTTP status, URL or raw
relay or native string; those stay in details. ErrorDetails renders
details under a collapsed "Details" disclosure.

The relay layer keeps failures typed instead of stringified:
- The service snapshot stores a RelayFailure ({ kind, details }) and
  channel windows and thread snapshots keep a WindowFailure. History
  parse failures are ReadError("invalid-response").
- The window store records errorSource "older" only when loadOlder
  failed, so a head or catch-up failure no longer blames older history.
- PublishNotSent carries the relay's wait for a quota hint or a held
  lane, so busy copy can say how long.
- Copy and retry share one set of fetch network messages (including
  WebKit's), and a body cut off after its headers is an unavailable read.
- An answer Buzz can't use is not retried at once. The service asks
  again in the background after 30 s, 1, 2 and every 5 minutes, keeping
  the failure on screen meanwhile.

History reads: ChannelBody, ChannelTimeline, NewMessage, ThreadPanel,
MediaReviewViewer and DraftPreview map the failure with historyFailure():
"Messages didn’t load." with "Try again" for an outage or 5xx; "Buzz is
busy. Try again in a few seconds." (or "in about N minutes/hours") when
paused; "Check for updates" and no Try again for a 4xx or invalid
answer; "You don’t have access to this conversation." for a refusal with
no known reason. The older edge says "Older messages didn’t load.".

Connection failures: Workflows and Notes show the Messages
ConnectionFailed card: "Can’t connect", "Check your connection. We’ll
keep trying." and "Try again".
An answer Buzz can't use reads "Buzz couldn’t connect to this
community." with "Check for updates"; a refusal shows the community
refusal title with no retry. Bestie, Pinned Bestie, Activity, its
preview and Reminders say the same through connectionFailureText and
connectionFailureAction. App's page failure cards put the primary
action last and drop Try again for an unusable connection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: mtoohey <mtoohey@squareup.com>
Five journeys still asserted the copy that the previous commit replaced:

- thread-window: the older-replies alert said "Older page failed"; it now
  says "Older replies didn’t load." (ThreadPanel's OlderRepliesFailure).
- media-controls, message-overlays, gallery-theme: MediaReviewViewer's
  unavailable-media button was "Retry"; it is "Try again". The
  message-overlays title and the gallery comment follow the rename.
- navigation-repairs: the keyboard hand-off journey failed the session
  with an empty JSON object. That is now an answer Buzz can't use, so
  the card says "Buzz couldn’t connect to this community." and offers
  Check for updates, not Try again. The journey now fails the broker
  registration with a recorded 502 instead, a failure the service leaves
  for the user, so the card still says "Check your connection, then try
  again." and offers Try again. Every caller registers the community, so
  every registration fails until the user acts, and the retry is counted
  from then on.

Product copy is unchanged.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@matt2e
matt2e force-pushed the errors/user-copy-messages branch from 4b2e055 to 32c31dc Compare November 9, 2026 16:06
…errors/profile-save

# Conflicts:
#	src/features/relay/user-copy.test.ts
#	src/features/relay/user-copy.ts
@matt2e
matt2e force-pushed the errors/user-copy-messages branch from 32c31dc to 0e35fbc Compare November 12, 2026 23:50

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant